今天來說說 Spring MVC 裡最後一個還沒談到的重要組件:HandlerExceptionResolver 是 Spring MVC 的例外處理核心樞紐,主要的用途是解耦「業務邏輯」與「錯誤處理」,確保框架不用在各流程上撰寫大量的 try-catch 邏輯,也能夠在異常發生的時候,正確捕獲並處理相關的程式例外。HandlerExceptionResolver 是 DispatcherServlet 處理流程中唯一的異常調度收攏點,無論邏輯內使用的是 @ExceptionHandler, @ResponseStatus 或框架自帶的預設錯誤,底層最後都會走到 HandlerExceptionResolver,並透過它完成例外的分流、解析、與 HTTP 的響應封裝。
HandlerExceptionResolver(後續簡稱 ExceptionResolver)本身是一個介面,內部只定義了一個 resolveException 方法,參數分別是 request、response、負責處理請求的 handler(如果異常發生在 handler 被找到之前,這裡就會是 null)、以及被拋出的 Exception 本身。resolveException 方法回傳的是 ModelAndView,DispatcherServlet 會依據內容把它解讀成 3 種語意。回傳 null 代表不處理異常,會根據調用鏈交給下一個 ExceptionResolver;回傳空的 ModelAndView 代表異常已經處理完成,不再做視圖渲染;而如果回傳的是帶有視圖名稱的 ModelAndView,DispatcherServlet 就會交給 ViewResolver 渲染出對應的錯誤頁面,這也是標題裡「錯誤視圖的解析轉換」指的部分。跟其他 MVC 組件一樣,ExceptionResolver 在容器內也存在多個實例,並按照 Ordered(或 @Order)排序後存成一個 List,以責任鏈的方式依序輪詢。
接著來看看 ExceptionResolver 的調用時機:在 doDispatch 裡,從 getHandler、preHandle、ha.handle 一路到 postHandle,都被包在同一個 try-catch 區塊裡面,過程中拋出的異常會被統一存進一個 dispatchException 變數,再交給 processDispatchResult。只要這個變數不為 null,DispatcherServlet 就會呼叫 processHandlerException 遍歷 ExceptionResolver 清單,只要有任何一個回傳非 null 的結果,就中斷輪詢並用這個結果收尾;但如果整條清單都回傳 null,異常就會被重新拋出,一路穿出 DispatcherServlet 交給 Tomcat 處理。這裡有一個小細節可以注意:異常被成功處理之後,攔截器的 afterCompletion 拿到的 Exception 參數固定是 null,因此如果專案有 @ControllerAdvice,在 afterCompletion 用「ex 是否為 null」判斷請求有沒有出錯,這個條件幾乎永遠不會成立。
以 Spring boot 的專案來說,預設的清單裡會有 2 個 ExceptionResolver:第一個是 Spring boot 追加的 DefaultErrorAttributes,它只負責把異常存進 request 的 attribute,接著就直接回傳 null;第二個則是 Spring MVC 創建的 HandlerExceptionResolverComposite,內部依序包了 3 個真正做事的實體類別。其中 ResponseStatusExceptionResolver 負責處理標記了 @ResponseStatus 的自定義異常與 ResponseStatusException;DefaultHandlerExceptionResolver 則負責把 Spring MVC 自己拋出的標準異常轉換成語意正確的 status code,例如 HandlerMapping 對上路徑、但請求方法不符時的 405、HttpMessageConverter 無法處理請求格式時的 415、或 HandlerMethodArgumentResolver 參數校驗失敗時的 400 ...等。這兩個組件都是透過 response.sendError 告知錯誤,本身不寫入任何 Response Body,所以處理完成後回傳的都是空的 ModelAndView。
最後,回收一下前面的伏筆:如果異常沒有被處理、或是被 sendError 標記,Spring boot 預先向內嵌 Tomcat 註冊的 /error 錯誤頁面就會接手,把請求重新轉發給 BasicErrorController,再搭配 DefaultErrorAttributes 存下來的異常資訊,組成我們熟悉的那包帶有 timestamp, status, error, path 的 JSON。也因為 ExceptionResolver 是 DispatcherServlet 的組件,作用範圍只限於 DispatcherServlet 內部,所以在 DispatcherServlet 之前執行的 Filter 裡如果拋出異常,是沒辦法被 @ControllerAdvice 捕獲的,只會落到 /error 的流程,導致錯誤格式跟其他 API 不一致,這點在 Filter 裡做 JWT 驗證之類的邏輯時要特別留意。